[Interactive Graph] Add logarithm graph state management and reducer#3422
[Interactive Graph] Add logarithm graph state management and reducer#3422
Conversation
🗄️ Schema Change: No Changes ✅ |
🛠️ Item Splitting: No Changes ✅ |
npm Snapshot: PublishedGood news!! We've packaged up the latest commit from this PR (ff4c2f6) and published it to npm. You Example: pnpm add @khanacademy/perseus@PR3422If you are working in Khan Academy's frontend, you can run the below command. ./dev/tools/bump_perseus_version.ts -t PR3422If you are working in Khan Academy's webapp, you can run the below command. ./dev/tools/bump_perseus_version.js -t PR3422 |
|
Size Change: +394 B (+0.08%) Total Size: 496 kB
ℹ️ View Unchanged
|
|
@claude review |
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
Show resolved
Hide resolved
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
SonicScrewdriver
left a comment
There was a problem hiding this comment.
This looks pretty much perfect to me, aside from the one issue regarding the asymptote bug (same one as the Exponential graph). I think it makes sense to fix it as part of this PR before releasing.
Feel free to grab me if you need help recreating
| describe("movePoint on a logarithm graph", () => { | ||
| it("rejects the move when both points would share the same y-coordinate", () => { | ||
| // Arrange — point 0 at y=-3, point 1 at y=-7; trying to move point 0 to y=-7 | ||
| const state = generateLogarithmGraphState(); |
There was a problem hiding this comment.
I really like these comments, they're honestly very helpful for understanding the tests. :) I might go back and add some clarity for the exponential ones after this
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
Outdated
Show resolved
Hide resolved
66a406e to
ce9f761
Compare
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
Show resolved
Hide resolved
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
Show resolved
Hide resolved
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.test.ts
Outdated
Show resolved
Hide resolved
packages/perseus/src/widgets/interactive-graphs/reducer/initialize-graph-state.ts
Outdated
Show resolved
Hide resolved
packages/perseus/src/widgets/interactive-graphs/reducer/interactive-graph-reducer.ts
Outdated
Show resolved
Hide resolved
|
|
||
| // Both points at the same x is invalid for an exponential | ||
| if (updatedCoords[0][X] === updatedCoords[1][X]) { | ||
| return state; | ||
| } | ||
|
|
||
| return { | ||
| ...state, | ||
| hasBeenInteractedWith: true, | ||
| coords: updatedCoords, | ||
| }; |
There was a problem hiding this comment.
🟣 This is a pre-existing issue in the exponential doMovePoint cross-asymptote reflection block: after calling boundAndSnapToGrid on the reflected point, there is no check that updatedCoords[otherIndex][Y] === asymptoteY. If otherPoint[Y] is non-grid-aligned (possible via startCoords, which are stored verbatim) and within snapStep/2 of the asymptote, the reflected Y snaps to exactly asymptoteY, committing an invalid state with a curve point on the horizontal asymptote. The logarithm case added in this PR correctly includes the guard if (updatedCoords[otherIndex][X] === asymptoteX) return state; the fix is to add the symmetric if (updatedCoords[otherIndex][Y] === asymptoteY) return state to the exponential block.
Extended reasoning...
What the bug is and how it manifests
In the exponential doMovePoint cross-asymptote reflection branch (interactive-graph-reducer.ts, lines 581–601), after calling boundAndSnapToGrid([otherPoint[X], reflectedY], state) to produce updatedCoords[otherIndex], there is no guard checking whether updatedCoords[otherIndex][Y] === asymptoteY. If snapping maps reflectedY to exactly the asymptote Y-value, the returned state has a curve point sitting directly on the horizontal asymptote — an invariant violation. The coefficient computation for an exponential curve depends on both points being strictly above or below the asymptote, so placing a point on it produces degenerate or undefined coefficients and silently blanks the rendered curve.
The specific code path that triggers it
getExponentialCoords stores startCoords.coords verbatim without snapping, so a question author can supply a point at a non-grid-aligned Y value. When the user drags the other point across the horizontal asymptote, the cross-asymptote branch fires and computes reflectedY = 2 * asymptoteY - otherPoint[Y]. If otherPoint[Y] is non-grid-aligned and within snapStep/2 of asymptoteY, then Math.round(reflectedY / snapStep) * snapStep resolves to exactly asymptoteY.
Step-by-step proof
- Setup:
asymptoteY = 0,snapStep = [1, 1], coords =[[1, 2], [2, 0.4]](the second point placed via non-grid-alignedstartCoords). - User drags point 0 below the asymptote to
[1, -1]. boundDestination[Y] = -1≠ 0 → on-asymptote guard passes.newCoords[0][X] = 1≠ 2 → same-X guard passes.movedSide = (-1 > 0) = false;otherSide = (0.4 > 0) = true→ sides differ, cross-asymptote branch entered.reflectedY = 2 * 0 - 0.4 = -0.4.boundAndSnapToGrid([2, -0.4], state)→Math.round(-0.4 / 1) * 1 = 0→ reflected point lands at(2, 0)= the asymptote Y.- The only post-reflection guard checks
updatedCoords[0][X] === updatedCoords[1][X](1 ≠ 2) → passes. - State is committed with
coords[1][Y] === asymptoteY— invalid.
Why existing code doesn't prevent it
The pre-reflection same-X guard (newCoords[0][X] === newCoords[1][X]) and the on-asymptote guard (boundDestination[Y] === asymptoteY) both check the dragged point, not the reflected other point. The post-reflection guard added by this PR checks only for same-X between the two curve points, not whether either point lands on the asymptote. None of these guards can catch the reflected-point-onto-asymptote scenario.
Why this is pre-existing and what the PR does
The exponential reflection logic — including the return { ...state, coords: updatedCoords } inside the cross-asymptote block — predated this PR (the return statement has no + prefix in the diff). This PR added the same-X guard and the comment // Mirrors the logarithm reducer, but did not add the analogous asymptote-Y guard. The logarithm case introduced in this very PR does correctly include if (updatedCoords[otherIndex][X] === asymptoteX) return state, demonstrating the author was aware of the pattern and applied it to the new code but left the pre-existing exponential gap unaddressed.
How to fix it
After computing updatedCoords[otherIndex] in the exponential cross-asymptote block, add:
if (updatedCoords[otherIndex][Y] === asymptoteY) {
return state;
}This is directly symmetric with the logarithm guard at line 660 and closes the gap.
There was a problem hiding this comment.
I can make a separate PR for adding this logic, as it doesn't make sense to change exponential logic unless you absolutely have to.
SonicScrewdriver
left a comment
There was a problem hiding this comment.
Happy to approve this now! Claude had a couple comments, but I don't consider them blockers.
…ema (#3420) ## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1.▶️ [Add logarithm graph type definitions and data](#3420) 2. [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4. [Add logarithm graph rendering, SR strings, and equation string](#3423) 5. [Add logarithm graph scoring](#3424) 6. [Add logarithm graph option in the Interactive Graph Editor](#3425) Add logarithm type definitions, this is the initial implementation for supporting Logarithm graph in Interactive Graph widget. - Add `PerseusGraphTypeLogarithm` and `LogarithmGraphCorrect` types to the data schema, following the exponential pattern (coords + asymptote) - Add JSON parser for the new `"logarithm"` graph type - Add `generateIGLogarithmGraph()` test data generator - Add placeholder cases in all exhaustiveness switches so the build stays green ## Details This is the first PR in the logarithm interactive graph series (LEMS-3950). It adds the type definitions with zero runtime behavior change — no graph renders, no scoring, no editor UI. **Data shape:** Logarithm follows the exponential pattern — two curve points plus an asymptote value. For logarithm the asymptote is the x-value of a vertical line (vs. exponential's y-value for a horizontal line). **Placeholder cases** were added to satisfy `UnreachableCaseError` exhaustiveness in: - `interactive-graph-editor.tsx` — graph merging switch - `start-coords/util.ts` — `shouldShowStartCoordsUI` (returns `false`), `getDefaultGraphStartCoords` (returns `undefined`) - `interactive-graph.tsx` — `getEquationString` (returns `""` — safe no-op since this is called unconditionally in the editor render path) - `initialize-graph-state.ts` — `initializeGraphState` (returns `type: "none"`) - `interactive-graph-ai-utils.ts` — `getGraphOptionsForProps` and `getUserInput` These placeholders will be replaced with real implementations in subsequent PRs. **Why `getEquationString` returns `""` instead of throwing:** `getEquationString` is called unconditionally in the editor's `render()` method (`interactive-graph-editor.tsx`). Since this PR adds the parser that accepts `type: "logarithm"`, any content with `correct.type === "logarithm"` (e.g. created via API) would reach the editor and crash the React render if the placeholder threw. All other placeholders in this PR are safe (return no-op values), but `getEquationString` is uniquely on the render path, so it must return a safe value. Future graph types should follow the same pattern: return `""` here, not throw. Issue: LEMS-3953 Co-Authored by Claude Code (Opus) ## Test plan - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Generator tests pass (2 new tests: default graph, graph with all props) - [ ] Parser regression tests pass (170 snapshots unchanged) - [ ] Interactive graph tests pass (199 tests) - [ ] Verify no runtime behavior change in Storybook (existing graph types still work) Author: ivyolamit Reviewers: claude[bot], ivyolamit, SonicScrewdriver, handeyeco Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3420
48b121b to
4f8ea19
Compare
## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1. [Add logarithm graph type definitions and data](#3420) 2.▶️ [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4. [Add logarithm graph rendering, SR strings, and equation string](#3423) 5. [Add logarithm graph scoring](#3424) 6. [Add logarithm graph option in the Interactive Graph Editor](#3425) Add the logarithm math utilities to kmath for supporting Logarithm graph in Interactive Graph - Add `LogarithmCoefficient` type and `getLogarithmCoefficients()` to kmath, following the exponential pattern - Compute coefficients `{a, b, c}` for `f(x) = a·ln(b·x + c)` using the inverse exponential approach - No canonical normalization needed (logarithm has no periodic equivalences, same as exponential) ## Details This adds the shared math utility for logarithm coefficient computation to `@khanacademy/kmath`, following the same pattern as `getExponentialCoefficients()`. Both the rendering component (PR 4) and scoring (PR 5) will consume this function. **Mathematical approach (inverse exponential):** 1. Flip each coordinate `(x, y) → (y, x)` — treating the logarithm as the inverse of an exponential 2. Use the asymptote x-value as the flipped exponential's `c` coefficient 4. Compute exponential coefficients `aExp`, `bExp` from the flipped points 5. Invert to get logarithm coefficients: `a = 1/bExp`, `b = 1/aExp`, `c = -cExp/aExp` **Validation guards** (returns `undefined` for invalid inputs): - Same y-coordinate on both points (makes `bExp` undefined) - A point lying on the asymptote - Points on opposite sides of the asymptote - Non-finite or zero intermediate results This matches the reference implementation in `packages/perseus-core/src/utils/grapher-util.ts` (the Grapher widget's `Logarithm` object, lines 449–558). Co-Authored by Claude Code (Opus) Issue: LEMS-3953 ## Test plan - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Coefficient tests pass (6 new tests, 17 total in coefficients.test.ts) - Grapher test data: `[-4,-3]`, `[-5,-7]`, asymptote `-6` reproduces correct y-values - Natural log: `[1,0]`, `[e,1]`, asymptote `0` → `a≈1, b≈1, c≈0` - Negative b: points left of asymptote (`y = ln(-x)`) → `b≈-1` - Same-y → `undefined` - Point on asymptote → `undefined` - Opposite sides → `undefined` Author: ivyolamit Reviewers: claude[bot], SonicScrewdriver, handeyeco Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful, ⚪️ 1 check is neutral Pull Request URL: #3421
…ithm graph state management and reducer for supporting Logarithm graph in Interactive Graph
…garithm graph state management and reducer
54a615a to
ff4c2f6
Compare
| const stillAllAbove = | ||
| coords[0][Y] > newY && coords[1][Y] > newY; | ||
| const stillAllBelow = | ||
| coords[0][Y] < newY && coords[1][Y] < newY; | ||
| if (!stillAllAbove && !stillAllBelow) { | ||
| return state; | ||
| } | ||
| } | ||
|
|
||
| // Final safety: asymptote must not land exactly on either point |
There was a problem hiding this comment.
🟡 The final safety check at lines 850-852 of the exponential doMoveCenter case (if (newY === coords[0][Y] || newY === coords[1][Y]) return state;) is now permanently unreachable dead code after this PR added the stillAllAbove/stillAllBelow guard. Both code paths through the function guarantee strict inequality between newY and each coord's Y value before reaching that check, so it can never fire. The comment 'Final safety: asymptote must not land exactly on either point' misleads future maintainers into thinking there is still a gap in the earlier guards.
Extended reasoning...
What the bug is
The PR introduced two changes to the exponential doMoveCenter case: (1) it replaced raw-range clamping with inset-range clamping, and (2) it added the stillAllAbove/stillAllBelow guard that returns state when the clamped asymptote position is still on or between the curve points. After these two changes, the immediately-following pre-existing safety check at lines 850-852 is logically unreachable.
Path 1 — snap-through not entered (allAbove or allBelow already true)
The snap-through block is skipped entirely. allAbove uses strict > so every coords[i][Y] > newY, meaning newY cannot equal any coord Y. allBelow uses strict < with the same conclusion. The final check therefore can never fire on this path.
Path 2 — snap-through entered
After computing the inset-clamped newY, the code checks stillAllAbove (strict >) and stillAllBelow (strict <). If \!stillAllAbove && \!stillAllBelow, the function returns state before reaching the final check. If execution continues, at least one of stillAllAbove or stillAllBelow is true — by the strict operators, newY strictly differs from every coord Y. Equality is impossible.
Step-by-step proof (the case that used to require the final check)
Before this PR, snap-through used raw-range clamping: clamp(newY, yRange[0], yRange[1]). If a curve point sat at the raw range boundary (e.g. y=10, range [-10,10]), snap-through computed newY = 10+1 = 11, raw-clamped back to 10 — exactly matching the point Y — and the final safety check fired. This PR added inset-range clamping and the stillAllAbove/stillAllBelow guard, eliminating both root causes, but did not remove the now-dead check.
The new logarithm case confirms the intent
The logarithm doMoveCenter block added by this PR is structured identically to the updated exponential block, but correctly omits the final safety check entirely. This shows the author understood the guard makes the check redundant in new code, but did not remove the redundant check from the existing exponential case.
Why existing code does not prevent the confusion
The comment 'Final safety: asymptote must not land exactly on either point' implies there is a reachable equality case the earlier guards do not handle. This is false after this PR's changes. A reviewer noted they vaguely remembered an edge case that justified keeping it but could not recall the details — the lingering comment will perpetuate that uncertainty for future maintainers. The check should be removed along with its comment.
…uation string (#3423) ## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1. [Add logarithm graph type definitions and data](#3420) 2. [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4.▶️ [Add logarithm graph rendering, SR strings, and equation string](#3423) 5. [Add logarithm graph scoring](#3424) 6. [Add logarithm graph option in the Interactive Graph Editor](#3425) Create the logarithm graph visual component, add Storybook coverage, SR strings, and equation string for supporting Logarithm graph in Interactive Graph - Create the logarithm graph visual component (`logarithm.tsx`) with curve rendering, draggable asymptote, and movable points - Add 6 screen reader strings for accessibility - Add equation string display for the editor - Add Storybook story ## Details This is the largest PR in the series. It creates the visual rendering of the logarithm graph, following the exponential component pattern with the axis swapped (vertical asymptote instead of horizontal). **Curve rendering:** - Single `<Plot.OfX>` with domain restricted to one side of the asymptote (`[asymptoteX + 0.001, xMax]` or `[xMin, asymptoteX - 0.001]`) - Plot function computes `a * ln(b*x + c)`, returning NaN when outside domain or when y-value exceeds visible range + padding (prevents curve visually touching the asymptote) - `coeffRef` caches last valid coefficients as fallback during transient invalid states **Asymptote rendering:** - Uses existing `MovableAsymptote` component with `orientation="vertical"` - Dispatches `actions.logarithm.moveCenter()` on drag - `constrainAsymptoteKeyboard()` implements snap-through logic for keyboard navigation (mirrors exponential's vertical version but on X-axis) **Keyboard constraints:** - `getLogarithmKeyboardConstraint()` prevents points from landing on the asymptote's x-coordinate or sharing y-value with the other point - Uses bounded retry (max 3 steps) to skip past invalid positions **Equation string:** - `getLogarithmEquationString()` displays `y = a*ln(b*x + c)` with computed coefficient values - `defaultLogarithmCoords()` provides fallback coords (normalized fractions `[0.55, 0.55]`, `[0.75, 0.75]`) **Screen reader strings (6):** - `srLogarithmGraph` — graph container label - `srLogarithmPoint1` / `srLogarithmPoint2` — point position labels - `srLogarithmAsymptote` — asymptote label with keyboard instructions - `srLogarithmDescription` — graph state description - `srLogarithmInteractiveElements` — interactive elements summary Co-Authored by Claude Code (Opus) Issue: LEMS-3953 ## Test plan: - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Logarithm component tests pass (15 new tests): - 9 screen reader tests (aria-labels, descriptions, interactive elements, updates on state change) - 3 keyboard constraint tests for points (valid move, skip asymptote, skip same-y) - 3 keyboard constraint tests for asymptote (free move, snap-through, skip point x-value) - [ ] Interactive graph tests pass (178 passed, 28 skipped) - [ ] Verify rendering in Storybook (logarithm story renders correctly, curve matches expected shape) Author: ivyolamit Reviewers: claude[bot], ivyolamit, SonicScrewdriver Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3423
## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1. [Add logarithm graph type definitions and data](#3420) 2. [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4. [Add logarithm graph rendering, SR strings, and equation string](#3423) 5.▶️ [Add logarithm graph scoring](#3424) 6. [Add logarithm graph option in the Interactive Graph Editor](#3425) Add logarithm graph scoring to support the Logarithm graph in Interactive Graph - Add logarithm scoring to `score-interactive-graph.ts` using direct coefficient comparison - No canonical normalization needed (same as exponential) ## Details Follows the exponential scoring pattern exactly. Computes `{a, b, c}` coefficients for both the user's answer and the rubric using `getLogarithmCoefficients` from kmath, then compares with `approximateDeepEqual`. **Scoring logic:** - Returns `invalid` if guess coords or asymptote are missing, or if coefficient computation fails for either side - Returns `earned: 1` if `[a, b, c]` coefficients approximately match - Returns `earned: 0` otherwise This means two different sets of control points that define the same logarithmic curve will score as correct — the comparison is on the mathematical function, not the specific points chosen. Co-Authored by Claude Code (Opus) Issue: LEMS-3953 ## Test plan: - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Scoring tests pass (44 total, 6 new logarithm tests): - [ ] Invalid: undefined guess, null coords, null asymptote - [ ] Correct: matching coefficients - [ ] Incorrect: different coefficients - [ ] Equivalent curves: different control points producing same `y = ln(x)` coefficients → correct Author: ivyolamit Reviewers: claude[bot], SonicScrewdriver Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3424
…ph Editor (#3425) ## Summary: PR series to add logarithm graph support to the Interactive Graph widget: 1. [Add logarithm graph type definitions and data](#3420) 2. [Add logarithm math utilities to kmath](#3421) 3. [Add logarithm graph state management and reducer](#3422) 4. [Add logarithm graph rendering, SR strings, and equation string](#3423) 5. [Add logarithm graph scoring](#3424) 6.▶️ [Add logarithm graph option in the Interactive Graph Editor](#3425) Add logarithm graph option in the Interactive Graph Editor - Add `StartCoordsLogarithm` component for configuring logarithm start coordinates in the editor - Gate the "Logarithm function" option behind the `interactive-graph-logarithm` feature flag - Export `getLogarithmCoords` for use by the editor - Add start asymptote validation ## Details This PR enables content creators to configure logarithm exercises in the Interactive Graph editor, following the exponential editor pattern. **StartCoordsLogarithm component:** - Two coordinate pair inputs (Point 1 and Point 2) - A single number input for the asymptote x-position (labeled "Asymptote x =") - Equation display showing `y = a * ln(b*x + c)` with computed coefficient values - CSS module styling (not Aphrodite), following `start-coords-exponential.module.css` **Feature flag:** "Logarithm function" appears in the graph type selector only when `interactive-graph-logarithm` is enabled, preventing content creators from selecting it until it's ready for launch. **Editor validation:** The start asymptote x-value is validated — it cannot fall between or on the x-coordinates of the curve's start points (mirrors exponential's y-axis validation but for x-axis). **Exports:** `getLogarithmCoords()` is now exported from `initialize-graph-state.ts` and re-exported from `@khanacademy/perseus` for use by the editor's start-coords UI. Co-Authored by Claude Code (Opus) Issue: LEMS-3969 ## Test plan: - [ ] `pnpm tsc` passes - [ ] `pnpm knip` passes - [ ] `pnpm lint` passes - [ ] `pnpm prettier . --check` passes - [ ] Editor tests pass (422 total across 15 suites) - [ ] Verify in Storybook: "Logarithm function" appears in graph type selector when feature flag is on - [ ] Verify start coords UI renders correctly with asymptote input and equation display Author: ivyolamit Reviewers: claude[bot], SonicScrewdriver, ivyolamit Required Reviewers: Approved By: SonicScrewdriver Checks: ⏭️ 1 check has been skipped, ✅ 10 checks were successful Pull Request URL: #3425
Summary:
PR series to add logarithm graph support to the Interactive Graph widget:
Add logarithm graph state management and reducer for supporting Logarithm graph in Interactive Graph
LogarithmGraphStateto the internal state type systemmovePoint+moveCenter) with logarithm-specific constraintsDetails
This PR adds the state management layer for logarithm graphs, following the exponential pattern throughout. Logarithm is the vertical-asymptote mirror of exponential's horizontal-asymptote design.
Action registration: Reuses existing
movePointandmoveCenteraction creators (no new action types). Theactionsexport object getslogarithm: { movePoint, moveCenter }, identical to exponential.Reducer —
doMovePoint:reflectedX = 2 * asymptoteX - otherX) so both points end up on the same side — matches Grapher widget behaviorReducer —
doMoveCenter:Initialization:
getLogarithmCoords()followsgetExponentialCoords()pattern — returns{coords, asymptote}. Default coords use normalized fractions[0.55, 0.55]and[0.75, 0.75]to ensure both points are to the right of the default asymptote at x=0 after normalization (x=0.5 would land exactly on the asymptote).Placeholders:
mafs-graph.tsxreturns{graph: null, interactiveElementsDescription: null}for logarithm (replaced in PR 4).mafs-state-to-interactive-graph.tshas the real serialization (not a placeholder).Co-Authored by Claude Code (Opus)
Issue: LEMS-3953
Test plan:
pnpm tscpassespnpm knippassespnpm lintpassespnpm prettier . --checkpassesmovePoint: same-y rejection, bounding-to-same-y rejection, valid move, on-asymptote rejection, cross-asymptote reflectionmoveCenter: valid move, snap-through between points, Y-component ignored, final safety rejection